昨天我們已經在 SpringBoot 專案內完成了 Redis Client 的基本配置,今天實際用固定窗口計數器來整合 Redis 分散式儲存限流計數的功能,並且建立一個測試用的 Dockerfile 跟新的 docker-compose.yaml 來在容器內運行起兩個實例,測試看看分散式的環境下,是否可以共享 Redis 的狀態、做到統一限流。
首先我們先新增一個用 Redis 實作的計數器,由於之前已經有一個用 ConcurrentHashMap 實作的計數器了,它們都會 implements RateLimiterStorage 在 Spring 容器的要求下需要在 @Component 為他們做顯式命名,不然到時限流 class 要注入時只會注入介面,不知道會使用到哪個計數器:
@Component("redisRateLimiterStorage")
@RequiredArgsConstructor
public class RedisRateLimiterStorage implements RateLimiterStorage {
    private final RedisHelper redisHelper;
    @Override
    public long incrementAndSetExpire(String key, long expireSeconds) {
        return redisHelper.incrementWithCustomTTL(RedisKey.RATE_LIMITER.getValue(), expireSeconds, key);
    }
    // 略...
}
在這之前我已經先做 RedisHelper 工具類新增一個方法以供使用:
@Component
@RequiredArgsConstructor
public class RedisHelper {
    private final RedisTemplate<String, Object> redisTemplate;
    public long incrementWithCustomTTL(String cacheName, long ttl, Object... keys) {
        var key = getCacheKey(cacheName, keys);
        var count = redisTemplate.opsForValue().increment(key);
        if (count != null && count == 1) {
            redisTemplate.expire(key, Duration.ofSeconds(ttl));
        }
        return count != null ? count : 0L;
    }
    private String getCacheKey(String cacheName, Object... args) {
        if (args == null || args.length == 0) {
            return cacheName;
        }
        return cacheName.concat("::").concat(StringUtils.join(args, ":"));
    }
}
這邊要注意,如果要使用 @Qualifier("redisRateLimiterStorage") 指定要注入的實作類,就只能用建構子注入:
@Component
public class FixedWindowLimiter implements RateLimiterStrategy {
    private final RateLimiterStorage storage;
    public FixedWindowLimiter(@Qualifier("redisRateLimiterStorage") RateLimiterStorage storage) {
        this.storage = storage;
    }
    @Override
    public boolean isAllow(String key, RateLimiter rateLimiter) {
        var currentCount = storage.incrementAndSetExpire(key, rateLimiter.window());
        return currentCount <= rateLimiter.limit();
    }
    @Override
    public Algorithm getAlgorithmType() {
        return Algorithm.FIXED_WINDOW;
    }
}
因為我們要使用 docker-compose 來啟動 docker-image,所以必須先把我們自己的服務 build 成 docker-image,為此需要寫一個 Dockerfile:
FROM openjdk:17-jdk-slim
WORKDIR /app
COPY mvnw .
COPY .mvn .mvn
COPY pom.xml .
RUN chmod +x ./mvnw
RUN ./mvnw dependency:go-offline -B
COPY src ./src
RUN ./mvnw clean package -DskipTests
EXPOSE 8080
CMD ["java", "-jar", "target/playground-module-0.0.1-SNAPSHOT.jar"]
接著才能用 docker-compose 拉 image 來使用:
services:
  app1:
    build: ..
    container_name: test-app1
    environment:
      - SPRING_PROFILES_ACTIVE=test
      - REDIS_HOST=host.docker.internal
      - REDIS_PORT=6379
      - REDIS_PASSWORD=${REDIS_PASSWORD}
      - SERVER_PORT=8080
    ports:
      - "8081:8080"
    extra_hosts:
      - "host.docker.internal:host-gateway"
    restart: unless-stopped
  app2:
    build: ..
    container_name: test-app2
    environment:
      - SPRING_PROFILES_ACTIVE=test
      - REDIS_HOST=host.docker.internal
      - REDIS_PORT=6379
      - REDIS_PASSWORD=${REDIS_PASSWORD}
      - SERVER_PORT=8080
    ports:
      - "8082:8080"
    extra_hosts:
      - "host.docker.internal:host-gateway"
    restart: unless-stopped
如此一來就有兩個實例被運行起來了,分別是 8081 和 8082:
http://localhost:8081/rate/window/fixed
http://localhost:8082/rate/window/fixed
輪流 call 這兩隻 api 會發現它們是對同一個 Redis 裡面的資料結構做操作:
81 先 call → 計數 +1 = 1
82 再 call → 計數 +1 = 2
.
.
.
兩個合計訪問到第六次就會達到上限了,這樣就完成最基本的分散式限流架構。